Resolve tag calls at compile time instead of through the metaclass - #16134
Resolve tag calls at compile time instead of through the metaclass#16134codeconsole wants to merge 74 commits into
Conversation
The closure form is deprecated and carries no callable signature, so a tag defined that way cannot be resolved when a page is compiled. This was the last closure-based tag remaining in the repository.
Discovering which tags exist required loading every tag library and reflecting over it, which is only possible once the application is running. A GSP therefore had no way to know at compile time whether a tag call would resolve. The TagLib AST transformation now records each tag library's namespace and tag names as it is compiled, writing one descriptor per class under META-INF/grails/taglibs along with a manifest naming them. Descriptors are per class so that tag libraries packaged in separate jars merge on the classpath with no build step combining them, in the manner of META-INF/services entries. Deriving tag names from the AST has to agree exactly with the runtime rules in TagMethodInvoker, since a tag recorded in the index but rejected at runtime would resolve when a page is compiled and then fail when it renders. The framework method exclusions are shared rather than duplicated, and TagLibraryIndexAgreementSpec asserts the two views match for every framework tag library. Two cases the AST view has to account for: trait application generates super-accessor bridges that are synthetic at runtime but not marked so at canonicalization, and parameters with default values expand into overloads that reflection sees but the declaration does not show.
The type checking extension answered every unresolved tag call with makeDynamic, so compileStatic on a GSP verified model fields and left tag calls exactly as dynamic as they were without it. Tag calls are now checked against the tag library index. A call into a namespace backed by a compiled tag library must name a tag that library declares, and a misspelling is reported when the page is compiled rather than surfacing as a missing method when it renders. Namespaces the index does not know, as a tag library registered at runtime or supplied by a separately compiled plugin would be, keep resolving dynamically. Namespaces contributed by compiled tag libraries no longer have to be declared through the taglibs directive, because the index already states which tags they hold.
Dispatching a tag read Method.getParameters() on every invocation to work out which parameter takes the attribute map, which takes the body, and which are bound from named attributes. That allocates a fresh Parameter array and materialises reflection metadata each time, and it showed up directly in profiles of tag-heavy pages, yet the answer is fixed for a given method. The classification is now computed once, when the tag library class is first seen, and held alongside the method. Invocation walks the precomputed plan instead of re-reading reflection metadata, and the access check is suppressed once rather than paid per call. Also corrects two disagreements between the compile-time index and runtime dispatch that the framework tag libraries did not exercise: @tag and @NotATag override the conventional signature rule at runtime and now do so when scanning the AST, and an attributes parameter has to be assignable to Map, so an untyped parameter is not a dispatchable tag and is no longer recorded as one. IndexEdgeCaseTagLib covers both directions.
The index is written per tag library class specifically so that libraries packaged in separate jars combine on the classpath without a build step merging them. That is the central claim of the format and was previously only exercised indirectly, through tag libraries that all happened to live in one module. Builds classpaths out of temporary jars and asserts that two jars contributing to one namespace merge, that distinct namespaces stay distinct, that an empty classpath yields an empty index rather than failing, and that a malformed descriptor leaves its tags unknown so they fall back to dynamic resolution.
A design review found the compile-time index and runtime dispatch disagreeing in ways the framework tag libraries never exercise. Each would let a page compile and then fail as it renders. An attributes parameter is only recognised at runtime when it is named "attrs", unless the class was compiled without parameter names, in which case any name is accepted. The scanner checked only the type, so a tag written as foo(Map options) was recorded but is not dispatchable. Whether names are retained is read from the compiler configuration and the same rule applied, with the body parameter treated the same way. TagMethodInvoker scans declared methods, so a tag inherited from a base class is not dispatchable. The scanner walked inherited methods too and is now restricted to declarations on the tag library itself. Trait methods are woven as declarations and remain visible. A namespace is read at runtime through the class hierarchy and after its initialiser has run. The scanner looked only at the class itself and treated anything other than a constant as the default namespace, filing those tags under "g". It now walks the hierarchy, and when the namespace cannot be known without running the code the tag library is left out of the index rather than filed under a guess. An unrecognised tag is now a warning rather than a compilation error. The index describes the tag libraries compiled before a page, so a tag added without rebuilding its library, or a library registered at runtime, would otherwise fail a build whose pages are correct. Setting grails.views.gsp.strictTagChecking restores the error.
When more than one tag library declares the same namespace and tag, the one registered last wins, and registration order comes from artefact scanning rather than from the classpath. TagPrecedenceSpec pins that down: the winner flips purely with registration order and carries no inherent ranking, and returnObjectForTags follows the winner rather than accumulating. The index cannot reproduce that ordering, so it no longer tries. A tag declared by two tag libraries is recorded as ambiguous and is not resolved, which leaves the choice where it is actually made. Resolving it here would risk compiling against one implementation and dispatching to another. The same tag library reaching the classpath twice, as a duplicated dependency does, names one implementation and stays resolvable. Descriptors also carry the format version they were written with, and one written by a different version is ignored rather than read under rules that may since have changed.
Whether a method is a tag was decided in two places: by reflection when an application registers its tag libraries, and over the syntax tree when the tag library index is written. Keeping the two in step was left to a test, and they had already drifted apart three times. The rules now live in TagDiscoveryRules, over a TagMethodView that a compiled method and a method being compiled each adapt to. The two sources differ in only two respects, both confined to their adapters: parameter defaults have already become overloads by the time a class is reflected on, and whether parameter names survive into the class file is a property of the compilation rather than of the method. TagDiscoveryRulesSpec compiles one matrix of method shapes and classifies each of them twice, from the tree and from the resulting class, asserting the two agree as well as asserting the expected answer. It covers the shapes that caused the earlier drift: a Map parameter not named attrs, an untyped parameter, @tag and @NotATag, a framework trait name, and a defaulted trailing parameter.
The index was written as each tag library compiled, which left it unable to describe the source set as a whole. A renamed or deleted tag library kept its descriptor, and the manifest naming it, until the build directory was cleaned, so the index went on describing tags that no longer existed. TagLibraryIndexGenerator now writes it for a whole source directory at once, and clears what was there first, so what it describes is what exists. Sources are parsed only as far as the syntax tree, never loaded or executed, which is covered by a tag library whose static initialiser would throw if it ran. Regenerating unchanged sources produces a byte-identical index. The generateTagLibraryIndex Gradle task runs it, before page compilation and ahead of the artifact being packaged, so a project depending on this one can resolve its tags. The generator reads source rather than classes, so its classpath is the compile classpath alone: including this project's own output made it wait for the compilation it exists to precede, which showed up as a circular dependency through compileAstGroovy. Two tests hold that ordering in place. The AST transformation keeps writing descriptors, which covers tag libraries compiled outside this task.
Registering a tag library asked the class what tags it declares, which walks its metaclass properties, reflects over its declared methods and scans its fields. That happens for every tag library as an application starts, and the answer was already worked out when the tag library was compiled. Registration now prefers the tags recorded in the index, and discovers them from the class only when there is no record. That keeps working unchanged for a plugin built before the index existed, for a tag library registered while an application is being developed, and for one registered by a test. A tag declared by more than one tag library is deliberately absent from the index, so a tag library holding such a tag falls back to discovery rather than registering an incomplete set.
Resolving a tag installed it onto the caller's metaclass so that later calls bypassed methodMissing, and every namespace dispatcher was built with its own ExpandoMetaClass carrying a method for each tag in the namespace. Tag dispatch was therefore a read of an initialised ExpandoMetaClass, guarded by a read-write lock that profiles of concurrent rendering showed to be the largest single contended cost, and every caller mutated its own metaclass the first time it used a tag. Both now dispatch through the tag library lookup, which is a map read. Removing the installed methods is not simply removing a cache: they carried overloads that adapted a CharSequence body into a closure and routed the call through the output capture protocol. Dispatching straight at the tag library skipped that and broke a tag called with a string body. The dynamic path therefore goes through methodMissingForTagLib, which already does both, with the flag that installs the metaclass methods turned off. NoMetaClassMutationSpec holds the property that resolving a tag writes to no metaclass.
Now that the index is generated from source before anything resolving tag calls is compiled, it describes the tag libraries of this project as well as those of its dependencies, so a tag it cannot find in a namespace it knows is a misspelling rather than a gap in what it has seen. Those are reported as compilation errors. A namespace with no compiled tag library is still left to runtime resolution, as a tag library registered while developing or supplied by a plugin built before the index existed would be, and a tag declared by two tag libraries stays ambiguous and unresolved. Setting grails.views.gsp.strictTagChecking to false turns the error back into a warning. Generating the index no longer fails when one tag library cannot be resolved ahead of compilation. FormFieldsTagLib refers to services in its own project, which by design are not on the classpath the generator runs against, and that took the whole index down with it. Sources that fail are parsed individually and those that still fail are named and skipped, leaving them to be described by the compiler as they are built.
Calling a tag reaches the tag library through invokeMethod, which leaves a dynamic call site in the caller's bytecode even when that caller is statically compiled. Once a tag has been resolved against the index there is nothing left to decide beyond which bean holds it, so the call can be an ordinary method call. CompiledTagInvocation is that call. It takes the namespace and name as arguments and ends at TagOutput.captureTagOutput, which is where the dynamic path ends too, so attribute and body handling, output capture, encoding and return-object behaviour are the same either way. TagLibNamespaceMethodDispatcher, which is how a statically compiled page reaches a tag, now goes through it. This is the target a rewritten call site needs. Rewriting the call sites themselves is not part of this commit.
Every tag library had every tag in every namespace installed onto its metaclass as it was constructed, and again for the whole application at plugin bootstrap, so that a tag library calling another tag found a method rather than falling through to methodMissing. A namespace resolved through propertyMissing was installed as a property too. None of that is needed now that tags are resolved through the tag library lookup and invoked through CompiledTagInvocation, so it is gone. Registering a tag library with the lookup is all bootstrap does. TagLibraryMetaUtils is deprecated. What remains of it is the dynamic dispatch a tag library registered at runtime still relies on, reached with metaclass installation switched off. The compile-time warning for a closure-based tag now says what the consequence is, that calls to it stay dynamic because it cannot be resolved when a page is compiled, and shows the method form to use instead.
Writing g.link(controller: 'book') reaches the tag library through propertyMissing to find the namespace and invokeMethod to find the tag, which leaves a dynamic call site in the bytecode of a tag library even when it is statically compiled. Both names are fixed in the source and the index says whether that tag exists, so the call is replaced with a call to CompiledTagInvocation. Only calls whose shape is evident from the source are rewritten: a tag takes attributes, a body, both or neither, written as literals. A call whose attributes are assembled at runtime, a namespace no compiled tag library declares, a tag declared by more than one of them, and a namespace shadowed by a field of the same name are all left to resolve as they did before. CompiledTagCallRewriterSpec renders through each of those shapes, since a rewrite that changed behaviour is the failure that matters. Behaviour alone cannot show that anything was rewritten, because the dynamic route produces the same output, so CompiledTagCallBytecodeSpec compiles a tag library and looks for the invocation in the class file, and for its absence where nothing should have been rewritten.
A review of the stack found the strict check and the explicit invocation path each breaking cases the dynamic path handled. An unrecognised tag is a warning again rather than an error. Knowing that a namespace holds some compiled tag libraries is not knowing that it holds all of them: a plugin built before the index existed contributes tags to g without a descriptor, a tag library registered while an application runs contributes more, and the index generator skips a source it cannot resolve ahead of compilation. In each case the namespace is known but incomplete, so a tag missing from it is not necessarily a misspelling. Failing the build needs a namespace able to state that it is complete, which the descriptors cannot yet do. grails.views.gsp.strictTagChecking opts in to the error. A tag declared by more than one tag library was reported as no such tag. The index deliberately leaves it unresolved so that runtime precedence decides, which the checker read as absent. It now asks whether the tag is ambiguous before reporting it. A tag body given as text threw a GroovyCastException. The dynamic path accepted text through overloads that wrapped it in a closure, and the explicit API narrowed the body to Closure, which a namespaced dispatcher call with a string body could not satisfy. The API takes the body as it is given and wraps text, as before. Registering a tag library after startup, as reloading a changed class during development and registering one from a test both do, uses the descriptor supplied rather than the one recorded when the class was compiled, which no longer describes what is being registered.
A tag library rewrote its own tag calls as it compiled, but a controller can call tags as well. It gains that from the tag library invoker trait rather than from being a tag library, so nothing rewrote its calls and they stayed dynamic. A global transformation now rewrites tag calls in any class carrying that trait, which covers controllers without naming them and without a second copy of the rules. It runs after trait injection, since whether a class can call tags is only settled once its traits are applied, and it does nothing at all when no compiled tag library is on the classpath. ControllerTagCallRewriteSpec compiles a class with the trait and one without, and looks in the class files for the invocation, since a rewritten call and a dynamic one produce the same output.
Describes how tag libraries are described when compiled and how that resolves tag calls, what is compiled into a direct invocation and what stays dynamic, how an unrecognised tag is reported and how to turn that into an error, why a closure-based tag cannot be resolved, and where the description is written and packaged. Adds the corresponding what's new entry and an upgrade note covering the two things an existing application notices: the warning for an unrecognised tag, and the warning for a closure-based tag with the method form to replace it.
The pre-compilation task scanned only grails-app/taglib, so a project keeping tag libraries elsewhere had them described as they compiled rather than beforehand, which is later than anything resolving them in the same compilation needs. The task now takes a collection of directories, defaulting to the one it scanned before, and the generator can add to an index rather than always replacing it, so several directories contribute to one index instead of each erasing the last.
Three places were still installing methods onto metaclasses, so the earlier claim that dispatching a tag writes to none of them was wider than what had actually been done. A page had methodMissing installed onto its metaclass as it compiled, along with a method for every tag and a property for every namespace. GroovyPage declares methodMissing itself now and already resolved a namespace through getProperty, so a page reaches the same tags without any of those writes. The template namespace installed a method for each template name the first time it was used. Rendering goes through the render tag either way, so the name is resolved rather than installed. The unit testing support keeps installing tag methods, deliberately. Tests call tag methods directly, and the installed methods substitute an empty body for a missing one, so tagLib.someTag(attrs, null) works. Removing it broke twelve FormTagLibTests cases that rely on that calling convention. A running application does not depend on it. NoMetaClassMutationSpec now covers the template namespace and the page, alongside the namespace dispatcher it already covered.
The index said only that a tag existed, which is enough to tell a misspelling from a real tag but not enough to decide whether a call to it can be bound. A tag defined as a Closure field carries no signature, so a call to it cannot become a direct invocation, and nothing in the index said which tags those were. Each tag is now recorded with its kind, and a call is only compiled into a direct invocation when the tag is a method. A closure-based tag stays known, so it is never reported as a misspelling, and stays dynamically dispatched. This showed up immediately: g.link is a Closure field, so calls to it are correctly left alone. The descriptor format is version 2 as a result. A descriptor written by another version is ignored rather than read under the wrong rules, and a kind that cannot be recognised is treated as the dynamic one so that a newer descriptor can never cause a call to be bound wrongly.
A namespace is not declared anywhere: it is reached because nothing else answers to the name. The rewriter took any receiver that was not this or super as a namespace, checking only for a field of that name on the class itself, so a local variable, a parameter, an inherited field or a getter-only property called g had calls on it rewritten into tag invocations. The object the author wrote was then never called, and the code still compiled, which is the worst way for this to go wrong. A receiver that resolves to anything - a local, a parameter, a field, a property - is that thing, and the field and property checks now walk the hierarchy and consider getters. Rewriting is also confined to methods declared by the class being transformed. getMethods() reaches inherited methods, whose bodies belong to the class that declared them, so a subclass able to call tags could otherwise change a superclass that cannot. TagCallShadowingSpec covers a local, a parameter, a typed local, a field, an inherited method, and the unshadowed case that must still be rewritten.
The index a project generates from its own sources reached page compilation and the packaged artifact, but not the compilation of its own controllers and tag libraries. Those could resolve tags from dependencies while a call to a tag declared in the same project stayed dynamic, which is not what the documentation described. The generated directory now joins the compile classpath, and compileGroovy waits for it. It goes onto the classpath rather than into the source set output, which would make the index wait for the compilation it exists to precede. The documentation is also narrowed to what is actually rewritten. Expressions in a GSP page are checked against the descriptions but are not rewritten: a page selects the tag by name as it renders, through the namespace dispatcher, which no longer touches a metaclass but is still a runtime choice. An unqualified call such as message(code: 'x') is likewise left alone, since whether that name is a tag or a method of the calling class is decided where it is called. The examples now use a method-based tag, since the closure-based g.link they used is one of the calls that is deliberately not rewritten.
Groovy reads a property from getX() and, when the return type is boolean, from isX() as well. Only the first was checked, so a class declaring boolean isG() had this.g treated as a tag library namespace and calls on it rewritten, sending them to a tag library instead of the property the author wrote. Both forms now claim the name, with the isX form requiring a boolean return type as Groovy does. TagCallShadowingSpec covers each getter form and an inherited getter. Also proves the resolution the previous commit exists to enable. Generating an index from a tag library source, putting it on a compile classpath and compiling a controller that calls that namespace shows the call becoming an invocation, and shows it staying dynamic without the index. The build wiring is asserted separately; what was missing was evidence that the wiring is sufficient for the compiler to resolve the call.
Reading walks every jar on the classpath. A compiler that consulted the index for each source file walked it once per file, while the one caller that did cache it held the result in a static field, which carried one project's tag libraries into the next compilation in the same Gradle daemon and read them from the wrong class loader. It is read once per class loader instead, which is once per compilation and no longer. Asking what a single tag library declares is answered from its own descriptor. It used to be answered by scanning every namespace and then discarding the answer entirely if any namespace anywhere held a tag two libraries declared, so one overridden tag left every tag library undescribed. A tag two libraries declare is now reported as known even though it cannot say which of them will answer to it, so that it is never mistaken for a misspelling, and the settings a build states about its tag libraries are read alongside the descriptors.
Registration preferred the tags recorded when a tag library was compiled over the tags the class has, to save discovering them by reflection as the application starts. It saved nothing: the tag library class is constructed before it is registered, and constructing it already reads every tag by reflection and through the metaclass. What it did add was a way for a descriptor left behind by an earlier build to decide what a running application believes a tag library declares. Reflection is authoritative at runtime; the index describes what was true when the tag library was compiled and is used where that is the question being asked.
|
I posted the AI review, I'm not sure I agree with splitting it. I think it found enough real issues that we can use this to iterate on the review. @codeconsole |
An unqualified call to a DefaultGroovyMethods method - with, each, print and the rest - reached that method directly and never went near methodMissing. Rewriting it into a tag invocation because a tag library happened to declare a tag of the same name silently sent the call somewhere it was never written to go. grails-fields already declares f:with, so the collision is not hypothetical. Reserve every name the metaclass answers to for an arbitrary receiver, which covers DefaultGroovyMethods and any extension module on the compiling classpath. A call that names its namespace is unaffected.
…name A page resolves an unqualified name through a real methodMissing rather than one installed onto its metaclass, and the field it resolves against is documented as null until the page is initialised. Reaching the lookup regardless arrives at the same missing-method answer, but only because a dynamic call on a null receiver yields no tag library; say it instead, and pin the behaviour with a spec.
The rewriting has to run after the transforms that apply the traits a class calls tags through, and did - but only because a transform that declares no priority defaults to zero, which happened to put it last among the globals. A transform added later with the same default would have displaced it silently. Give it a slot in GroovyTransformOrder, as every other Grails global transform has, and pin the relationship to artefact trait injection.
…alls onto metaclasses The guide claimed every tag library describes itself when no build writes the index. That path is a local AST transform bound to @taglib, so it reaches annotated tag libraries only; one declared by convention is recognised as an artefact too late to describe itself. Say so, along with the descriptors that path leaves behind when a tag library is renamed. Deprecate the metaclass-installing methods individually rather than the class that holds them: methodMissingForTagLib is the dynamic dispatch path and is not going anywhere. Record the removed and no-op metaclass API in the upgrade guide, along with the closure tag form tag libraries are actually written in. Also drop LocalNameCollector's use of a deprecated Groovy API, which the build was reporting, and collect the two names it was missing - a catch parameter and a closure's implicit it.
The descriptor format ships for the first time in this release, so starting it at two makes the number mean nothing later. Nothing outside these files reads the constant. Also remove the javadoc left above its replacement on FRAMEWORK_METHOD_NAMES and on findTags.
findTagNames duplicated findTags with the same two loops and the same declaring-class guard, and getIncompleteNamespaces exposed a field the isNamespaceComplete question already answers. Neither had a caller anywhere, in main code or in a test.
A test source set builds its runtime classpath from the main output rather than from the main runtime classpath, so the packaged index never reached it: a page rendered by a test resolved its tags against an index missing the application's own tag libraries. Add it to the test and integrationTest runtimes, and assert it. The generator now reads sources with the encoding the project compiles with rather than always UTF-8, and the settings file is written through the key constants rather than through literals that repeat them. Generated AST nodes are built per call site instead of reusing the process-wide THIS_EXPRESSION and NULL singletons, which carry node metadata that a statically compiled class writes to. Drop the developmentMode fields nothing reads - one of which the trait materialised into every controller and tag library - and the empty @PostConstruct that remained once tags stopped being installed onto metaclasses. Also cover the build with a configuration cache run, which the index tasks turn out to survive, and pin the on-disk format on both sides of the module boundary that has to restate it.
…re missing Descriptor URLs are resolved against the manifest that names them rather than searched for on the classpath, which turned one full classpath walk per tag library into none. Pin the three ways these discovery rules differ from the ones they replaced - an Object member name, a non boolean is accessor, and a name containing a dollar - through both the tree and the compiled class, so the claim that the two views cannot drift is enforced for them too. Document the tag call that a controller annotated outside grails-app/controllers does not get compiled, with a spec pinning both shapes, and the GrailsTagException a resolved call now reports for a tag the runtime has not registered. Cover grails-mail's text:newLine, which had no test before its signature was changed here, and say why it was changed: it works either way, but a closure tag now warns and the framework should not trip its own warning. Drop getAmbiguousTagNames, which restates isAmbiguous, and the benchmark spec that was gated off by an environment variable and so never ran.
…e review Every tag library compiled into one directory adds itself to a manifest they all share, unguarded. Writing 32 of them at once lost 29: each writer put back a copy that had never seen the others. A lost entry is silent - the descriptor is there, nothing names it, so the tag library is never discovered. Guard the read-modify-write with a monitor for threads of this JVM and a file lock for a second process, and prove it with a spec that fails without either. Pin what a class calling a tag through methodMissing now gets back: both ways of declaring a tag capture output, where a method-declared one used to return its own return value. Record that in the upgrade guide. Say plainly when strictTags can be used - a namespace only some of its jars described cannot be checked, which is the normal state of g - and correct the claim that a tag's kind decides whether a call is resolved. It does not; both forms are dispatched by name.
request.withFormat { form multipartForm { } } was compiled into a call to
the g:form tag. form there is a format in a DSL: a closure is given a
delegate when it runs, and a name the delegate answers to is the
delegate's, not a tag library's. Nothing about that is knowable when the
closure is compiled, so an unqualified call inside one is left alone. A
call naming its namespace is unaffected, which is how a tag body keeps
the faster path.
Restore TagLibrary.initializeTagLibrary. It does nothing now, but a trait
method is part of the binary contract - Groovy weaves a call to the
generated helper into every implementing class, so removing it raised
NoSuchMethodError for every tag library compiled against an earlier
release, asset-pipeline's among them.
An import left unused when the manifest read moved to a channel, a blank line left by removing a method, a groovy.lang import group that should not be separated from org.codehaus.groovy, and an import moved out of its group when @PostConstruct was restored.
It compiled a file into a temporary grails-app/controllers directory and relied on the artefact injector recognising it by location. That passed on macOS and failed on Ubuntu and Windows, in every CI run and again on a rerun, and I could not reproduce it locally in any configuration - alone, with --rerun-tasks, or with the whole module suite. The behaviour is not in doubt: every application under grails-test-examples declares its controllers that way and has its tag calls compiled. What is left here keys on the trait, which is what the rewriting actually reads, and on the annotated case that the trait reaches too late.
sbglasius
left a comment
There was a problem hiding this comment.
Reviewed the whole diff. This is a large, well-reasoned change and the inline rationale throughout made it unusually easy to follow. I traced the Gradle wiring and found no task-dependency cycle, and I checked the two AST/reflection agreement risks I was most worried about empirically — Groovy's default-parameter overloads are neither synthetic nor stripped of parameter names, so TagDiscoveryRules.hasInvocableTagShape's optional-arity logic does agree with what reflection sees at runtime. Argument order between the rewriter and CompiledTagInvocation matches for both the page and non-page overloads, invokeTag/<g:set> index positions are right, and the REWRITTEN_MARKER correctly stops the local @TagLib transform and the global one from double-reporting.
Five things I'd like your read on, left as inline comments. Two themes:
Index ↔ runtime agreement is the load-bearing invariant. TagLibraryIndexAgreementSpec and TagDiscoveryRulesSpec pin the method rules well, but closure-field discovery isn't covered by the agreement test — and that's where the first comment lands. An agreement case for closure fields, including inherited ones, would close the gap.
The dynamic fallback (methodMissingForTagLib) is now load-bearing where it wasn't. Three call sites were redirected into it, and its argument-shaping switch silently swallows anything outside the three canonical shapes. That was tolerable when it only backed g.foo(...); comments 3 and 4 are the same root cause at runtime and at compile time.
One thing I couldn't resolve either way, so I've left it out of the inline comments: the closure-delegate reasoning is applied to unqualified method names but not to the receiver. A namespaced call is rewritten inside a closure even though g could equally be answered by the closure's delegate. I couldn't construct a realistic case where it misfires — the plausible delegates (withFormat, markup builders) don't answer to namespace names — but it is the symmetric hole to the one you explicitly guard, and may be worth a sentence in the Limitations section.
| tags.put(method.getName(), TagLibraryIndexEntry.Kind.METHOD); | ||
| } | ||
| } | ||
| for (FieldNode field : classNode.getFields()) { |
There was a problem hiding this comment.
findTags() scans classNode.getFields(), which is declared-only, but DefaultGrailsTagLibClass registers Closure-typed tags by walking the whole superclass chain (and the metaclass property list, which also includes inherited properties). So an inherited closure tag exists at runtime but is missing from the index.
abstract class BaseTagLib { Closure common = { attrs -> } }
class MyTagLib extends BaseTagLib { static namespace = 'my' }<my:common/> renders, because DefaultGrailsTagLibClass's field loop walks getSuperclass(). But MyTagLib's descriptor is written with an empty tag list, so isKnown('my','common') is false while isNamespaceComplete('my') is true — and with strictTags = true a call to my.common(...) fails compilation with "No such tag [common] in namespace [my]" for a tag that works. Without strictTags it just silently stays on the dynamic path.
Note the method side is consistent (both sides are declared-only, and IndexEdgeCaseTagLib/BaseEdgeTagLib pins that) — it's only the closure-field branch that diverges.
There was a problem hiding this comment.
Fixed. Enumeration is now shared the way classification already was: a TagLibraryView over a syntax tree or a compiled class, and one walk in TagDiscoveryRules that both TagLibraryAstDiscovery and DefaultGrailsTagLibClass route through. Method tags are read from the declaring class, closure tags up the hierarchy, matching dispatch.
TagSetAgreementSpec asserts the two views produce the same set; the three inherited-closure rows fail without the walk.
Two smaller divergences went with it: the AST used equals on Closure where the runtime uses isAssignableFrom, and closure-vs-method precedence is now decided once rather than falling out of loop order.
|
|
||
| List<File> roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList(); | ||
| List<File> skipped = new ArrayList<>(); | ||
| for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) { |
There was a problem hiding this comment.
This iterates every ClassNode parse() produced, which includes the sources SourceRootClassNodeResolver pulled in from resolutionRoots — not just the ones under sourceDirs. Combined with the bare-name fallback in isTagLibrary() (getName().endsWith("TagLib"), line 378), classes that were never in sourceDirectories and are not tag library artefacts get descriptors written for them.
GroovyPagePlugin sets resolutionSourceRoots to every main Groovy source root, which in a Grails app is src/main/groovy, grails-app/services, grails-app/controllers and the rest. So a helper like src/main/groovy/com/acme/BaseTagLib.groovy, referenced as a superclass by a real tag library, gets added to the compilation unit by the resolver, matches endsWith("TagLib"), resolves to the default namespace g, and has every (Map)-shaped method recorded as a g tag.
Two outcomes, both unwanted: if one of those names collides with a real g tag, TagLibraryIndex.load() marks it ambiguous and every g.<thatTag>(...) call in the project silently stops being rewritten (pure perf loss, invisible); if it doesn't collide, g.<helper>(...) compiles into a direct CompiledTagInvocation call that throws GrailsTagException at runtime, and strictTags no longer reports it.
Restricting the loop to class nodes whose source file was in sourceDirs would fix it without touching the resolver.
There was a problem hiding this comment.
Confirmed the core of this — the loop does describe everything parse() returned, resolver-pulled sources included, and the bare-name fallback then files a src/main/groovy helper's (Map) methods under g. One correction on the first outcome, worth having before anyone fixes it: a collision does not stop rewriting. isKnown deliberately answers true for an ambiguous tag and the invocation binds by name at runtime, so the colliding case is benign — the harm is confined to the non-colliding phantom (compiles into an invocation that throws GrailsTagException at runtime) and to strictTags no longer reporting a real misspelling that matches a phantom name. Restricting the loop to sourceDirs is still the right fix; see the new comment on isTagLibrary for the abstract-class case that restriction does not cover.
There was a problem hiding this comment.
Fixed. Descriptors are written only for class nodes whose source file was in sourceDirectories; a collaborator the resolver added to read a type is no longer described. TagLibraryIndexGeneratorSpec covers a *TagLib-named helper under a resolution root, and fails without the filter.
There was a problem hiding this comment.
Noted on the collision — you are right that isKnown answers true for an ambiguous tag and the invocation binds by name, so that case is benign; the harm is the non-colliding phantom and the missed strictTags report. The sourceDirs restriction is in, and the abstract case is fixed separately on your other comment.
| // ExpandoMetaClass the first time it used a tag, and made every later call pay the | ||
| // read lock guarding an initialised metaclass. The tag is dispatched through the | ||
| // lookup each time instead, which is a map read. | ||
| return TagLibraryMetaUtils.methodMissingForTagLib( |
There was a problem hiding this comment.
This changes dispatch semantics for a name that is both a tag and a plain overload. The old tagLibrary.invokeMethod(methodName, args) dispatched on the real argument list; methodMissingForTagLib takes the tag branch as soon as hasInvokableTagMethod(tagBean, name) is true, and only then reshapes the arguments through a switch that understands arity 0, arity 1, and arity 2-with-a-Map. Every other argument list falls through with attrs = [:] and body = null.
Given a tag library with both:
def format(Map attrs) { ... } // a tag
def format(String value, String pattern) { ... } // a plain helpera controller calling format('2026-08-19', 'yyyy') previously reached tagLibrary.invokeMethod and ran the two-String overload. Now hasInvokableTagMethod is true, the case 2 branch sees args[0] is not a Map, and captureTagOutput invokes format([:]) — both arguments silently discarded, wrong output, no error.
(The respondsTo fallback further down only runs when the name isn't an invokable tag method, so it doesn't catch this.)
There was a problem hiding this comment.
Confirmed, including the before: at the merge-base this line was tagLibrary.invokeMethod(methodName, args), which dispatched format('2026-08-19', 'yyyy') to the two-String overload on the bean. methodMissingForTagLib itself is unchanged by this PR — its tag branch and argument switch predate it — so the regression is purely that this call site now routes into it. Whatever the fix looks like, it needs to land in both halves: guarding only the rewriter (the sibling comment) leaves this runtime path discarding the same arguments.
There was a problem hiding this comment.
Fixed. methodMissingForTagLib takes the tag branch only when the argument list is a shape a tag can be called with — none, one, or two whose first is a Map. Anything else falls through to the method lookup, which finds the overload.
TagLibraryInvokerDispatchSpec covers format(Map) beside format(String, String) called with two Strings; it runs the tag with no attributes without the fix.
There was a problem hiding this comment.
Landed in both halves: methodMissingForTagLib only takes the tag branch for an argument list a tag can be called with, and the rewriting declines to compile a shape the invocation cannot account for. Specs cover the runtime path and the bytecode path separately.
| invocationArgs.addExpression(transform(argument)); | ||
| } | ||
| return new StaticMethodCallExpression(INVOCATION_TYPE, | ||
| page ? INVOKE_ARGUMENTS_IN_CONTEXT : INVOKE_ARGUMENTS, invocationArgs); |
There was a problem hiding this comment.
Same root cause as the TagLibraryInvoker comment, but baked into bytecode. When attributesAndBody() can't classify the arguments, the rewrite emits invokeArguments/invokeArgumentsInContext, whose switch discards any argument list that isn't 0 args, 1 arg, or 2 args starting with a Map.
With the same format(Map attrs) / format(String, String) pair, an unqualified format('2026-08-19', 'yyyy') in another tag library has declaresMember('format') == false on the caller and isKnown('g','format') == true, so it's rewritten to CompiledTagInvocation.invokeArguments(lookup, 'g', 'format', '2026-08-19', 'yyyy'). arguments.length == 2, arguments[0] isn't a Map, so attrs stays emptyMap() and body stays null — the tag runs with no attributes, no exception, and no compile-time diagnostic. Before the rewrite this reached the real method via methodMissing.
The comment on invokeArgumentsInContext says it deliberately mirrors methodMissingForTagLib including "its treatment of argument lists that match none of them", which is faithful — but the shapes that previously never reached that code now do. Rejecting the rewrite when the argument list matches none of the known shapes would keep those calls on the old path.
There was a problem hiding this comment.
Fixed, and then made narrower. The rewriting now declines to compile an argument shape the invocation cannot account for, so those calls stay on the dynamic path.
This was the fourth bug of the same family in this branch — DefaultGroovyMethods names, closure delegates, withFormat, and this — so the rule changed rather than gaining a fourth exclusion: rewriting an unqualified call is now off unless a build sets grails.compileStatic.unqualifiedTagCalls. Namespaced calls, markup tags and statically compiled page expressions are unaffected, which is where the measured benefit came from.
| if (sourceSets == null) { | ||
| return | ||
| } | ||
| for (String name : TEST_SOURCE_SET_NAMES) { |
There was a problem hiding this comment.
sourceSets.findByName(name) is eager, and configureProject runs inside pluginManager.withPlugin('groovy') — early. A source set registered by a plugin applied later returns null and is skipped with no diagnostic, which is exactly the gap the method's own comment says it exists to close ("Without this a page rendered from a test resolves its tags against an index missing the application's own tag libraries").
integrationTest is the one at risk, since it's typically registered by grails-gradle's integration-test support rather than by the Java plugin. sourceSets.configureEach { if (it.name in TEST_SOURCE_SET_NAMES) ... } (or matching) would make the wiring order-independent.
There was a problem hiding this comment.
Fixed. sourceSets.matching { it.name in TEST_SOURCE_SET_NAMES }.configureEach { } instead of the eager lookup, so integrationTest is picked up whenever it is registered.
|
I took the liberty to have my Claude review your code. |
A Closure tag declared on a base class is registered at runtime, which walks the superclass chain for Closure-typed fields, but was missing from the index, which read declared fields only. The namespace still counted as completely described, so under strictTags a call to a working tag failed the build, and without it the call silently stayed dynamic. The rules already had one statement of whether a method is a tag, so the two sides could not disagree about that. They had two statements of which members to ask about and how far up the hierarchy, which is where they did disagree. Give enumeration the same treatment: a TagLibraryView over a syntax tree or a compiled class, one walk in TagDiscoveryRules, and a spec asserting the two views produce the same set. The walk also settles two smaller differences the same way the runtime does: a field typed as a subclass of Closure is a tag, and a name declared both as a closure and as a method is the closure.
Three things sbglasius found, all where the compiled path and the dynamic one disagreed about what a call means. The generator described any class named *TagLib that the compilation produced, which includes the collaborators the resolver adds to read a type. A helper under src/main/groovy would be filed as a tag library of the default namespace, making its methods g tags that either collide with real ones, silently disabling rewriting for that name, or resolve to a tag that does not exist when the call runs. Only the sources the generator was pointed at are described now. A tag takes attributes, a body, or both. Any other argument list was reduced to a call with neither, silently dropping what was written, so a name that is both a tag foo(Map) and a helper foo(String, String) ran the tag with nothing where it used to reach the helper. Dynamic dispatch now leaves such a call to the method lookup, and the rewriting declines to compile a shape the invocation cannot account for. Test source sets are matched as they are created rather than looked up on the groovy plugin being applied, since integrationTest is registered later and was being skipped without a word - the gap that wiring exists to close.
jdaugherty
left a comment
There was a problem hiding this comment.
Re-review of the fix round
I checked out the updated branch and went through all twelve commits against each item from my last review. Every item is addressed: the reserved-name seeding, the per-site AST nodes, the declared transform order with its spec, the dispatch-semantics spec and upgrade note, the index API trims and format reset, the pinned constants on both sides of the module boundary, the configuration-cache and test-classpath coverage in the wiring spec, the encoding convention, the manifest locking (the 29-of-32 loss reproduction was worth having), and the docs. Verification was targeted rather than a full suite run: I re-read each fix in the code and reproduced locally where a claim needed it.
I also went through sbglasius's five comments and can confirm all five against the code. The two dispatch ones I verified against the merge-base as well — the old trait path really did reach a real overload through invokeMethod, and methodMissingForTagLib itself is unchanged by this PR, so the regression is purely which call sites now route into it. The source-set one holds as an ordering fragility: with the stock plugin order grails-app registers integrationTest before grails-gsp configures, so it bites when grails-gsp is applied first or without the app plugin — configureEach is still the right fix. One correction on the generator one is in a reply there.
Two new items below, both reproduced:
- Abstract classes are described by the generator but can never be registered at runtime — details on
isTagLibrary. - The
:grails-taglibbuild prints a javac deprecation note again, introduced by the manifest-sibling resolution — details onresolveSibling.
| } | ||
| } | ||
| } | ||
| return classNode.getName().endsWith(TAG_LIB_ARTEFACT); |
There was a problem hiding this comment.
Abstract classes are described, but can never be tag libraries at runtime.
Reproduced by running the generator over a temp grails-app/taglib:
// grails-app/taglib/demo/BaseTagLib.groovy
abstract class BaseTagLib {
def common(Map attrs) { 'shared' }
}
// grails-app/taglib/demo/MyTagLib.groovy
@TagLib
class MyTagLib extends BaseTagLib { static namespace = 'my' }produces demo.BaseTagLib.properties with namespace=g and tags=common:METHOD. At runtime that tag exists nowhere: ArtefactHandlerAdapter.isArtefactClass rejects abstract classes (allowAbstract is false and TagLibArtefactHandler does not set it), so BaseTagLib is never registered — and MyTagLib's method tags are declared-only, so common is not a tag of my either. Every g.common(...) in the project then compiles into a direct invocation that throws GrailsTagException when it runs, and under strictTags a real misspelling that happens to match a phantom name is no longer reported.
Restricting the loop to sourceDirs (sbglasius's comment above) does not cover this case: grails-app/taglib is a source dir, and an abstract base living there for its subclasses to share is exactly the shape IndexEdgeCaseTagLib's own BaseEdgeTagLib fixture has.
Suggest skipping abstract ClassNodes here, which is the same rule the runtime applies — and it covers traits and interfaces for free, since their class nodes are abstract too. The @TagLib-annotated abstract case in TagLibArtefactTypeAstTransformation.writeIndexEntry wants the same check. Worth a test asserting that an abstract base with a (Map)-shaped method produces no descriptor.
There was a problem hiding this comment.
Fixed. Abstract class nodes are skipped in the generator and in TagLibArtefactTypeAstTransformation.writeIndexEntry, which covers traits and interfaces since their nodes are abstract too. TagLibraryIndexGeneratorSpec asserts an abstract base beside its subclasses produces no descriptor while the subclass is still described; it fails without the check.
| */ | ||
| private static URL resolveSibling(URL manifest, String fileName) { | ||
| try { | ||
| return new URL(manifest, fileName); |
There was a problem hiding this comment.
This reintroduces the javac deprecation note the last round removed.
Every java.net.URL constructor is deprecated since JDK 20, so on the JDK 21 baseline ./gradlew :grails-taglib:compileGroovy now prints:
Note: .../org/grails/taglib/index/TagLibraryIndex.java uses or overrides a deprecated API.
The awkward part is that the constructor is the right tool here: URI.resolve cannot resolve a relative name against a jar: URI (it is opaque), and round-tripping the manifest URL through URI breaks on characters getResources does not encode. So rather than replacing it, suppress it deliberately — @SuppressWarnings("deprecation") on resolveSibling with a sentence saying why the constructor stays — so the module builds quietly without the reason getting lost.
There was a problem hiding this comment.
Fixed as suggested — @SuppressWarnings("deprecation") on resolveSibling with the reason recorded: URI.resolve cannot resolve a relative name against an opaque jar: URI, so the constructor stays. The module compiles without the note.
|
|
||
| List<File> roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList(); | ||
| List<File> skipped = new ArrayList<>(); | ||
| for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) { |
There was a problem hiding this comment.
Confirmed the core of this — the loop does describe everything parse() returned, resolver-pulled sources included, and the bare-name fallback then files a src/main/groovy helper's (Map) methods under g. One correction on the first outcome, worth having before anyone fixes it: a collision does not stop rewriting. isKnown deliberately answers true for an ambiguous tag and the invocation binds by name at runtime, so the colliding case is benign — the harm is confined to the non-colliding phantom (compiles into an invocation that throws GrailsTagException at runtime) and to strictTags no longer reporting a real misspelling that matches a phantom name. Restricting the loop to sourceDirs is still the right fix; see the new comment on isTagLibrary for the abstract-class case that restriction does not cover.
| // ExpandoMetaClass the first time it used a tag, and made every later call pay the | ||
| // read lock guarding an initialised metaclass. The tag is dispatched through the | ||
| // lookup each time instead, which is a map read. | ||
| return TagLibraryMetaUtils.methodMissingForTagLib( |
There was a problem hiding this comment.
Confirmed, including the before: at the merge-base this line was tagLibrary.invokeMethod(methodName, args), which dispatched format('2026-08-19', 'yyyy') to the two-String overload on the bean. methodMissingForTagLib itself is unchanged by this PR — its tag branch and argument switch predate it — so the regression is purely that this call site now routes into it. Whatever the fix looks like, it needs to land in both halves: guarding only the rewriter (the sibling comment) leaves this runtime path discarding the same arguments.
| * @return whatever the tag produces | ||
| */ | ||
| public Object methodMissing(String name, Object args) { | ||
| return TagLibraryMetaUtils.methodMissingForTagLib(getMetaClass(), getClass(), gspTagLibraryLookup, |
There was a problem hiding this comment.
Keep the guard — stating the contract beats leaning on how dynamic dispatch happens to treat a null receiver, and the reworded comment plus GroovyPageMethodMissingSpec now say exactly what it does.
| * @param acceptsBody whether the tag can be called with a body | ||
| * @since 8.0.0 | ||
| */ | ||
| public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName, |
There was a problem hiding this comment.
Keep getTagNamesForClass and isClassDescribed — each pins an invariant nothing else states. lookup from the original list is in the same boat (still spec-only); given the agreement spec reads through it and it is the natural read API next to isAmbiguous, keeping all three is fine.
A namespaced call names the tag library it means. A bare name is a tag only when nothing nearer answers to it, and what answers to it is not fully visible when compiling: a method Groovy gives every object, a delegate an enclosing closure is handed when it runs, an overload the tag library also declares. Each of those has been a bug in this branch, found one at a time and fixed by adding another exclusion, which is a sign the rule was wrong rather than that the exclusions were incomplete. So unqualified rewriting is now off unless a build sets grails.compileStatic.unqualifiedTagCalls. Namespaced calls, markup tags and statically compiled page expressions are unaffected, which is where the measured benefit came from. The exclusions stay: turning it on widens which calls are considered, not which names may be captured.
Every other assertion of this compiles a source in isolation, which shows the transform works but not that a project reaches it: the index has to be generated, packaged, put on the compile classpath and read, and the rewriting has to run after the trait that lets the class call tags. The spec that did cover the convention path drove it by writing a source into a temporary grails-app/controllers directory, and passed on macOS while failing on Linux and Windows - recognising a controller by its location depends on where the compilation happens, not on what is being compiled. Reading the class file a real build produced has no such dependence, so this answers the same question wherever CI runs it.
Kind was written into every descriptor as name:KIND, parsed back out and exposed as isBindable, and nothing ever asked. A closure tag and a method tag are dispatched the same way - by name, when the call runs - so no decision turned on it, and the javadoc claiming it decided whether a call could be resolved was simply wrong. Dropping it takes the encoding out of the format, the enum and the accessor out of the API, and the precedence rule out of the walk, which now just collects names. FORMAT_VERSION is what makes this reversible: the distinction can come back when something needs it.
Strict checking asked whether a tag was in the index, and the index holds what the tag libraries on the classpath described. For a namespace this project declares that is the whole answer. For any other it is not: a plugin built before descriptors existed contributes tags to g without one, as does one declaring its tag libraries by convention without the GSP Gradle plugin, and a tag library registered at runtime contributes more. Reporting a tag missing from such a namespace failed builds over correct code, which made strictTags unusable for g - the namespace it would matter most for. The generator already knows which namespaces it described, so the task records them beside the settings, which are not packaged, and reporting is limited to those. strictTags now catches a misspelling of your own tags in your own namespaces and never complains about a plugin's.
An abstract class kept beside the tag libraries that share it was described, with its methods filed under the default namespace. Artefact handling never registers one, and a subclass does not inherit its methods as tags, so those tags existed nowhere: a call to one compiled into an invocation that throws when it runs, and a misspelling matching such a name stopped being reported. Skip abstract class nodes in the generator and in the self-describing path, which covers traits and interfaces too. Also suppress the URL constructor deprecation deliberately rather than leave the note the last round removed: URI.resolve cannot resolve a relative name against an opaque jar: URI, so the constructor stays.
new URL(URL, String) is deprecated since JDK 20, so compiling this module printed a deprecation note. The constructor is nonetheless the tool that works: a manifest inside a jar is addressed by an opaque jar: URI, which URI.resolve cannot resolve a relative name against, and round-tripping the URL through URI breaks on characters ClassLoader.getResources does not encode. Suppress it deliberately and record the reason, so the module compiles quietly without the reason being lost with it.
…at/taglib-compile-time-index-8.0.x # Conflicts: # grails-doc/src/en/guide/introduction/whatsNew.adoc # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
🚨 TestLens detected 14 failed tests 🚨Here is what you can do:
Test SummaryCI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1) > :grails-data-mongodb-core:test
CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1) > :grails-test-examples-app1:test
CI / Functional Tests (Java 21, indy=false) > :grails-test-examples-app1:test
CI / Functional Tests (Java 21, indy=true) > :grails-test-examples-app1:test
CI / Functional Tests (Java 25, indy=false) > :grails-test-examples-app1:test
SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) > :grails-test-examples-app1:test
🏷️ Commit: 86642ab Rerun ControlsNote Checks are currently running using the configuration below. Select tests to mute in this pull request: 🔲 CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation Reuse successful test results: 🔲 ♻️ Only rerun the tests that failed or were muted before Click the checkbox to trigger a rerun: ☑️ Rerun jobs Learn more about TestLens at testlens.app/docs. |
What
Tag libraries are described as they are compiled, and that description resolves tag calls in code compiled afterwards. A call whose namespace and tag are known becomes a direct invocation instead of being dispatched through the metaclass, and nothing is installed onto a metaclass to make dispatch work.
Defining tags
Calling tags
In a tag library or a controller:
A call that names its namespace is compiled the same way inside a closure — a tag body, a
withFormatblock, anything taking a block — as outside one, as is one in a constructor or a field initialiser. A call written without a namespace inside a closure is not: a closure is handed a delegate when it runs, and a name the delegate answers to is the delegate's rather than a tag's.request.withFormat { form multipartForm { } }is the case that settles it —formthere is a format in a DSL, not theg:formtag.The tag is still selected by name when the call runs, through the same lookup dynamic dispatch uses. A tag library that overrides another, one registered while the application is running, and the order tag libraries are registered in all decide the outcome exactly as before. Nothing is bound to a particular tag library class, so a tag declared by more than one of them, and a tag declared as a
Closurefield, are compiled the same way.A call written without a namespace is not compiled unless the build asks for it with
grails { compileStatic { unqualifiedTagCalls = true } }. Whether a bare name is a tag depends on what else answers to it, and not all of that is visible when compiling — a method Groovy gives every object, a delegate an enclosing closure is handed, an overload the tag library also declares — so by default such a call is dispatched exactly as before.The attributes and body are passed straight through where the call says what they are; where it does not — a map held in a variable, a single value the tag reads under its own name — the arguments are forwarded as written and adapted by the same rules dynamic dispatch applies.
Tags in pages
A page resolves a name against the model it was rendered with before it reaches a tag library, and that model is not known when the page compiles. A page therefore keeps resolving its tags as it always has, unless it declares
compileStatic:Declaring it reserves the namespace names for tag libraries there.
grails.views.gsp.compileStaticapplies it to every page. A tag written as markup,<g:createLink controller="book"/>, already compiles into a direct call and is unchanged.Checking tags
By default nothing is reported: a tag no compiled tag library declares is left to resolve at runtime, because a namespace can legitimately hold tag libraries carrying no description. An application whose tag libraries are all described can ask for an error instead:
grails { compileStatic { strictTags = true dynamicTagNamespaces = ['legacy'] // namespaces filled in while the application runs } }Strict checking applies to the namespaces this project's own tag libraries declare, which are the only ones whose contents are knowable when compiling. Every other namespace is left alone,
gincluded: a plugin built before descriptors existed contributes tags to it without one, so a missing tag there is as likely to be a plugin's as a mistake.dynamicTagNamespacesturns compile-time resolution off for a namespace completely — calls into it are never rewritten, never reported, and dispatched exactly as before.Strict checking applies where the source says a call is a tag: one naming its namespace, and one written as markup. A call written without a namespace is never checked, and a namespaced expression in a page is checked only where the page declares
compileStatic.Deprecation
Defining a tag as a
Closurefield warns at compile time. It still works and is called the same way; the form is deprecated because a closure carries no signature, so nothing about the call can be checked:Why
Profiling a running application attributed roughly 25% of samples on a tag-heavy page to reflective and metaclass tag dispatch, and about 10% to
ExpandoMetaClassread-lock contention.Every caller used to mutate its own
ExpandoMetaClassthe first time it used a tag; every namespace dispatcher was built with a metaclass carrying a method per tag; and plugin bootstrap installed every tag onto every tag library. None of that remains.Measured on a page performing 400 tag invocations, 8 concurrent, 105k warmup requests, same publish flow both sides:
8.0.xCompiling the calls is worth this much again on top, measured with metaclass removal present on both sides and only the rewriting varying:
Where the description comes from
Under the Grails Gradle plugin the index is written twice, because the two things reading it need different guarantees.
generateTagLibraryIndexruns before compilation, so a call to a tag the project itself declares resolves as it compiles. Reading from source it cannot describe everything — a tag library referring to a type written in Java, or generated by the build, is left out — so what it missed is recorded, and nothing in an incompletely described namespace is ever reported. It is never packaged.packageTagLibraryIndexruns afterwards with the project's own classes on the classpath, where every tag library resolves. That index is the one pages compile against, the one packaged, and the one a project depending on this one reads. Each run replaces it, so a renamed or deleted tag library cannot survive.A build that does not write the index — a plain
groovyc, or a build without the Grails Gradle plugin — has each tag library annotated@TagLibdescribe itself as it compiles. That fallback does not reach a tag library declared by convention: an unannotated class undergrails-app/taglibis recognised as an artefact later in the compilation than the descriptor is written, so without the Gradle plugin it contributes none. A tag with no description is dispatched dynamically, so nothing breaks; it simply does not take the faster path.What is not rewritten
dynamicTagNamespacesgis that thingunqualifiedTagCallscompileStatic<g:set>with,each,printand the rest ofDefaultGroovyMethods, plus any extension module on the compiling classpath. Those are real methods on every receiver, so a tag of the same name must not capture the call@Artefact('Controller')outsidegrails-app/controllers, which gains the ability to call tags later in the compilation than the rewriting runsLimitations
compileStaticpage. That is what declaring it means there. A page that has not declared it is unaffected.doWithDynamicMethods, loses to a tag of the same name when the call is written without a namespace. Declare the method on the class, name the namespace indynamicTagNamespaces, or call the tag with its namespace.tagLib.someTag(attrs, null)works. A running application does not depend on this.DefaultGrailsTagLibClassis built from. Three differences from the code it replaces:equals/hashCode/toStringand theGroovyObjectmembers are excluded by name rather than by full signature; a zero-argumentis*method is an accessor regardless of return type; and a name containing$is excluded. None is reachable by a tag that would otherwise have been discovered — the shape check rejects all three anyway — and each is pinned inTagDiscoveryRulesSpecthrough both the tree and the compiled class.GrailsTagExceptionrather thanMissingMethodException. This arises where the index knows a tag but the running application has not registered it — a plugin excluded, a tag library innonEnhancedTagLibClasses, a unit test mocking only some. Code catchingMissingMethodExceptionaround a tag call, or probing withrespondsTo, is affected. A call into an undescribed namespace still reportsMissingMethodException.